Skip to content

refactor(date-field): align DateField and DatePicker with Field composition#392

Draft
IzumiSy wants to merge 1 commit into
mainfrom
refactor/date-field-field-composition
Draft

refactor(date-field): align DateField and DatePicker with Field composition#392
IzumiSy wants to merge 1 commit into
mainfrom
refactor/date-field-field-composition

Conversation

@IzumiSy

@IzumiSy IzumiSy commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Motivation

DateField / DatePicker were the odd ones out in the form stack.

The rest of AppShell already separates responsibilities cleanly:

  • Form is the top-level form shell
  • Field.Root owns label / description / invalid / error presentation
  • Select, Combobox, and Autocomplete are exposed as bare controls that compose inside Field.Root

By contrast, the date controls mixed control logic and field chrome in one component via props such as label, description, errorMessage, and isInvalid. That made React Hook Form usage inconsistent with the rest of the library, and left the public API with multiple overlapping sources of invalid state.

This PR moves the date controls onto the same composition model as the rest of the form controls.

Design Decision

Interoperability with React Hook Form

Field.Root already mirrors React Hook Form's fieldState shape (isTouched, isDirty, invalid, error), so the natural integration point in this codebase is:

  • Field.Root for label / description / error presentation
  • the control itself for value, onChange, onBlur, name, and ref

That is already how the rest of the form stack wants to be used. The old date API blurred that boundary by letting the control also own visible error presentation through errorMessage and isInvalid.

This refactor makes the date controls work the same way as other form controls: they expose control-level behavior and include hidden proxy wiring so Field.Label, form submission, and form libraries can still target them naturally.

API Consistency with Existing Dropdown Controls

Select, Combobox, and Autocomplete are already exposed as bare controls:

  • they do not own visible label / description / error props
  • they support standalone usage through accessible naming (aria-label, aria-labelledby, id)
  • they compose with Field.Root when field chrome is needed

To keep the library internally consistent, DateField / DatePicker should follow that same model instead of introducing a separate "closed field" pattern just for date inputs.

This PR therefore reshapes the date controls around the same ideas:

  • standalone usage remains possible with accessible naming
  • field chrome is delegated to Field.Root
  • the control surface focuses on value entry, constraints, keyboard/calendar behavior, locale/timezone handling, and form wiring

Rationale for Reshaping Flag Props

The previous surface had several overlapping boolean- and message-driven sources of state:

  • isInvalid
  • errorMessage
  • isRequired
  • isDisabled
  • isReadOnly
  • validation derived from minValue / maxValue / isDateUnavailable

That made it easy to express ambiguous states, for example:

  • invalid with no clear reason
  • both external invalid state and internal validation competing
  • multiple top-level booleans describing the control's mode

The new shape makes those changes explicit:

Before After Why
isDisabled mode="disabled" Collapses mutually-related control state into one mode field.
isReadOnly mode="readonly" Same rationale as above; avoids parallel top-level booleans.
isRequired constraints.required Treats required-ness as an input rule, alongside other constraints.
minValue constraints.min Groups range rules together instead of scattering them across top-level props.
maxValue constraints.max Same as above.
isDateUnavailable constraints.unavailable Keeps date availability as part of the constraint model.
isInvalid Field.Root invalid Invalid presentation belongs to the field shell, not the date control.
errorMessage Field.Error / Field.Root error state Visible error rendering belongs to the field shell and integrates better with RHF.

In the same spirit, field chrome moved out of the controls entirely:

Before After
label Field.Label
description Field.Description

This keeps the date control closer to a single source of truth model: interaction and constraints belong to the control, while field-shell presentation belongs to the field shell.

Summary

  • Refactor DateField / DatePicker to align with the existing Form + Field + control architecture.
  • Replace field-chrome props on the date controls with a control-first API (mode, constraints, accessible naming, onBlur, ref/form wiring).
  • Update the date picker example, component docs, and test coverage to reflect Field.Root composition and the new API.

@IzumiSy
IzumiSy force-pushed the refactor/date-field-field-composition branch from 045166b to ccdac38 Compare July 16, 2026 08:02

@interacsean interacsean left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great to start to align the interface with the rest of the form-based components we have already and with RHF.  We should also bring updates to the Calendar component to keep them all aligned.

Ontop of this, I am concerned about whether we should be maintaining backwards compatibility at all and give future deprecation warnings, or try to survey if any of these components are in use already if we want to make a breaking change

  1. The field is not working within a Form. In the example page, there is a form to test this functionality and it has broken... AI analysis says:

"The refactor registers the date control as a Base UI form field via the internal useRegisterFieldControl. Base UI's Form.onSubmit only calls your onSubmit/onFormSubmit when zero registered fields are invalid; otherwise it preventDefault()s and focuses the first "invalid" field (Form.js, the if (invalidFields.length) branch). A field's validity is !invalid && validityData.state.valid, and validityData.state.valid starts at null and is only committed via queueMicrotask, gated behind shouldValidateOnChange() (date-field.tsx:156). Under the default validationMode="onSubmit" it never resolves to true, so the date field is permanently counted invalid"

A typed out-of-range / unavailable date shows a red border but no message, the control does its own validation internally but never reports that validity back up to Field.Root.

  1. Existing form components have { readOnly?: boolean, disabled?: boolean }

mode="editable" | "readonly" | "disabled" does not align, and should become two independent booleans

disabled and readOnly are orthogonal, not one axis. In HTML they mean different things — a disabled field isn't focusable, isn't submitted, and isn't validated; a read-only field is focusable, is submitted, and is validated

It breaks native/RHF interop. register() and a spread {...field} set disabled as a boolean; Base UI's own Field.Root cascades disabled as a boolean. With mode, every integration point needs a translation layer — and it's already caused a bug in this PR: Field.Root disabled flows to the group but not to calState/the state hook (they only read mode === "disabled")

  1. Re constraints. Again { required?: boolean } is the existing convention, not nested. And we could shift min|maxValue to just min|max top-level props, which is how a native number-based input expects this type of range restriction. However we would be passing objects not strings so perhaps retaining Value​ is valid

  2. There are direct imports from @base-ui/react/internals/*​ which is brittle as they are not semver protected, and sounds like it is linked to the form no longer submitting

5. We lost a few tests related to typing/auto-advance, day-clamping, leap years etc. Is there rationale / scope decision to this? 

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants